點擊進入React源碼調試倉庫。
React在構建用戶界面整體遵循函數式的編程理念,即固定的輸入有固定的輸出,尤其是在推出函數式組件之後,更加強化了組件純函數的理念。但實際業務中編寫的組件不免要產生請求數據、訂閱事件、手動操作DOM這些副作用(effect),這樣難免讓函數組件變得不那麽純,於是React提供use(Layout)Effect的hook,給開發者提供專門管理副作用的方式。
下面我們會從effect的數據結構入手,梳理use(Layout)Effect在render和commit階段的整體流程。
關於hook鏈表結構的基本概念我已經總結過壹篇文章:React hooks 的基礎概念:hooks鏈表。對函數組件來說,其fiber上的memorizedState專門用來存儲hooks鏈表,每壹個hook對應鏈表中的每壹個元素。use(Layout)Effect產生的hook會放到fiber.memorizedState上,而它們調用後最終會生成壹個effect對象,存儲到它們對應hook的memoizedState中,與其他的effect連接成環形鏈表。
單個的effect對象包括以下幾個屬性:
單純看effect對象中的字段,很容易和平時的用法聯系起來。create函數即我們傳入use(Layout)Effect的回調函數,而通過deps,可以控制create是否執行,如需清除effect,則在create函數中return壹個新函數(即destroy)即可。
為了理解effect的數據結構,假設有如下組件:
const UseEffectExp = () => {
const [ text, setText ] = useState('hello')
useEffect(() => {
console.log('effect1')
return () => {
console.log('destory1');
}
})
useLayoutEffect(() => {
console.log('effect2')
return () => {
console.log('destory2');
}
})
return <div>effect</div>
}
掛載到它fiber上memoizedState的hooks鏈表結構如下
例如useEffect hook上的memoizedState存儲了useEffect 的 effect對象(effect1),next指向useLayoutEffect的effect對象(effect2)。effect2的next又指回effect1.在下面的useLayoutEffect hook中,也是如此的結構。
fiber.memoizedState ---> useState hook
|
|
next
|
↓
useEffect hook
memoizedState: useEffect的effect對象 ---> useLayoutEffect的effect對象
| ↑__________________________________|
|
next
|
↓
useLayoutffect hook
memoizedState: useLayoutEffect的effect對象 ---> useEffect的effect對象
↑___________________________________|
effect除了保存在fiber.memoizedState對應的hook中,還會保存在fiber的updateQueue中。
fiber.updateQueue ---> useLayoutEffect ----next----> useEffect
↑ |
|__________________________|
現在,我們知道,調用use(Layout)Effect,最後會產生effect鏈表,這個鏈表保存在兩個地方:
基於上面的數據結構,對於use(Layout)Effect來說,React做的事情就是
render階段:函數組件開始渲染的時候,創建出對應的hook鏈表掛載到workInProgress的memoizedState上,並創建effect鏈表,但是基於上次和本次依賴項的比較結果,
創建的effect是有差異的。這壹點暫且可以理解為:依賴項有變化,effect可以被處理,否則不會被處理。
commit階段:異步調度useEffect,layout階段同步處理useLayoutEffect的effect。等到commit階段完成,更新應用到頁面上之後,開始處理useEffect產生的effect。
第二點提到了壹個重點,就是useEffect和useLayoutEffect的執行時機不壹樣,前者被異步調度,當頁面渲染完成後再去執行,不會阻塞頁面渲染。
後者是在commit階段新的DOM準備完成,但還未渲染到屏幕之前,同步執行。
通過整體流程可以看出,effect的整個過程涉及到render階段和commit階段。render階段只創建effect鏈表,commit階段去處理這個鏈表。所有實現的細節都是在圍繞effect鏈表。
在實際的使用中,我們調用的use(Layout)Effect函數,在掛載和更新的過程是不同的。
掛載時,調用的是mountEffectImpl
,它會為use(Layout)Effect這類hook創建壹個hook對象,將workInProgressHook指向它,然後在這個fiber節點的flag中加入副作用相關的effectTag。最後,會構建effect鏈表掛載到fiber的updateQueue,並且也會在hook上的memorizedState掛載effect。
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 創建hook對象
const hook = mountWorkInProgressHook();
// 獲取依賴
const nextDeps = deps === undefined ? null : deps;
// 為fiber打上副作用的effectTag
currentlyRenderingFiber.flags |= fiberFlags;
// 創建effect鏈表,掛載到hook的memoizedState上和fiber的updateQueue
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}
currentlyRenderingFiber 即 workInProgress節點
更新時,調用updateEffectImpl
,完成effect鏈表的構建。這個過程中會根據前後依賴項是否變化,從而創建不同的effect對象。具體體現在effect的tag上,如果前後依賴未變,則effect的tag就賦值為傳入的hookFlags,否則,在tag中加入HookHasEffect標誌位。正是因為這樣,在處理effect鏈表時才可以只處理依賴變化的effect,use(Layout)Effect可以根據它的依賴變化情況來決定是否執行回調。
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
// 從currentHook中獲取上壹次的effect
const prevEffect = currentHook.memoizedState;
// 獲取上壹次effect的destory函數,也就是useEffect回調中return的函數
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 比較前後依賴,push壹個不帶HookHasEffect的effect
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags;
// 如果前後依賴有變,在effect的tag中加入HookHasEffect
// 並將新的effect更新到hook.memoizedState上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps,
);
}
在組件掛載和更新時,有壹個區別,就是掛載期間調用pushEffect創建effect對象的時候並沒有傳destroy函數,而更新期間傳了,這是因為每次effect執行時,都是先執行前壹次的銷毀函數,再執行新effect的創建函數。而掛載期間,上壹次的effect並不存在,執行創建函數前也就無需先銷毀。
掛載和更新,都調用了pushEffect,它的職責很單純,就是創建effect對象,構建effect鏈表,掛到WIP節點的updateQueue上。
function pushEffect(tag, create, destroy, deps) {
// 創建effect對象
const effect: Effect = {
tag,
create,
destroy,
deps,
// Circular
next: (null: any),
};
// 從workInProgress節點上獲取到updateQueue,為構建鏈表做準備
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
// 如果updateQueue為空,把effect放到鏈表中,和它自己形成閉環
componentUpdateQueue = createFunctionComponentUpdateQueue();
// 將updateQueue賦值給WIP節點的updateQueue,實現effect鏈表的掛載
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// updateQueue不為空,將effect接到鏈表的後邊
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
函數組件和類組件的updateQueue都是環狀鏈表
以上,就是effect鏈表的構建過程。我們可以看到,effect對象創建出來最終會以兩種形式放到兩個地方:單個的effect,放到hook.memorizedState上;環狀的effect鏈表,放到fiber節點的updateQueue中。兩者各有用途,前者的effect會作為上次更新的effect,為本次創建effect對象提供參照(對比依賴項數組),後者的effect鏈表會作為最終被執行的主體,帶到commit階段處理。
useEffect和useLayoutEffect,對它們的處理最終都落在處理fiber.updateQueue上,對前者來說,循環updateQueue時只處理包含useEffect這類tag的effect,對後者來說,只處理包含useLayoutEffect這類tag的effect,它們的處理過程都是先執行前壹次更新時effect的銷毀函數(destroy),再執行新effect的創建函數(create)。
以上是它們的處理過程在微觀上的共性,宏觀上的區別主要體現在執行時機上。useEffect是在beforeMutation或layout階段異步調度,然後在本次的更新應用到屏幕上之後再執行,而useLayoutEffect是在layout階段同步執行的。下面先分析useEffect的處理過程。
與 componentDidMount、componentDidUpdate 不同的是,在瀏覽器完成布局與繪制之後,傳給 useEffect 的函數會延遲調用。
這使得它適用於許多常見的副作用場景,比如設置訂閱和事件處理等情況,因此不應在函數中執行阻塞瀏覽器更新屏幕的操作。
基於useEffect回調延遲調用(實際上就是異步調用) 的需求,在實現上利用scheduler的異步調度函數:scheduleCallback
,將執行useEffect的動作作為壹個任務去調度,這個任務會異步調用。
commit階段和useEffect真正扯上關系的有三個地方:commit階段的開始、beforeMutation、layout,涉及到異步調度的是後面兩個。
function commitRootImpl(root, renderPriorityLevel) {
// 進入commit階段,先執行壹次之前未執行的useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
...
do {
try {
// beforeMutation階段的處理函數:commitBeforeMutationEffects內部,
// 異步調度useEffect
commitBeforeMutationEffects();
} catch (error) {
...
}
} while (nextEffect !== null);
...
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
if (rootDoesHavePassiveEffects) {
// 重點,記錄有副作用的effect
rootWithPendingPassiveEffects = root;
}
}
這三個地方去執行或者調度useEffect有什麽用意呢?我們分別來看。
commit開始,先執行壹下useEffect:這和useEffect異步調度的特點有關,它以壹般的優先級被調度,這就意味著壹旦有更高優先級的任務進入到commit階段,上壹次任務的useEffect還沒得到執行。所以在本次更新開始前,需要先將之前的useEffect都執行掉,以保證本次調度的useEffect都是本次更新產生的。
beforeMutation階段異步調度useEffect:這個是實打實地針對effectList上有副作用的節點,去異步調度useEffect。
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
...
if ((flags & Passive) !== NoFlags) {
// 如果fiber節點上的flags存在Passive調度useEffect
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
因為rootDoesHavePassiveEffects
的限制,只會發起壹次useEffect調度,相當於用壹把鎖鎖住調度狀態,避免發起多次調度。
function commitLifeCycles(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
...
// layout階段填充effect執行數組
schedulePassiveEffects(finishedWork);
return;
}
}
在調用schedulePassiveEffects
填充effect執行數組時,有壹個重要的地方就是只在包含HasEffect的effectTag的時候,才將effect放到數組內,這壹點保證了依賴項有變化再去處理effect。也就是:如果前後依賴未變,則effect的tag就賦值為傳入的hookFlags,否則,在tag中加入HookHasEffect標誌位。正是因為這樣,在處理effect鏈表時才可以只處理依賴變化的effect,use(Layout)Effect才可以根據它的依賴變化情況來決定是否執行回調。
schedulePassiveEffects的實現:
function schedulePassiveEffects(finishedWork: Fiber) {
// 獲取到函數組件的updateQueue
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
// 獲取effect鏈表
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
// 循環effect鏈表
do {
const {next, tag} = effect;
if (
(tag & HookPassive) !== NoHookEffect &&
(tag & HookHasEffect) !== NoHookEffect
) {
// 當effect的tag含有HookPassive和HookHasEffect時,向數組中push effect
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}
在調用enqueuePendingPassiveHookEffectUnmount
和enqueuePendingPassiveHookEffectMount
填充數組的時候,還會再異步調度壹次useEffect,但這與beforeMutation的調度是互斥的,壹旦之前調度過,就不會再調度了,同樣是rootDoesHavePassiveEffects
起的作用。
此時我們已經知道,effect得以被處理是因為之前的調度以及effect數組的填充。現在到了最後的步驟,執行effect的destroy和create。過程就是先循環待銷毀的effect數組,再循環待創建的effect數組,這壹過程發生在flushPassiveEffectsImpl
函數中。循環的時候每個兩項去effect是由於奇數項存儲的是當前的fiber。
function flushPassiveEffectsImpl() {
// 先校驗,如果root上沒有 Passive efectTag的節點,則直接return
if (rootWithPendingPassiveEffects === null) {
return false;
}
...
// 執行effect的銷毀
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = ((unmountEffects[i]: any): HookEffect);
const fiber = ((unmountEffects[i + 1]: any): Fiber);
const destroy = effect.destroy;
effect.destroy = undefined;
if (typeof destroy === 'function') {
try {
destroy();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
}
// 再執行effect的創建
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = ((mountEffects[i]: any): HookEffect);
const fiber = ((mountEffects[i + 1]: any): Fiber);
try {
const create = effect.create;
effect.destroy = create();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
...
return true;
}
useLayoutEffect在執行的時候,也是先銷毀,再創建。和useEffect不同的是這兩者都是同步執行的,前者在mutation階段執行,後者在layout階段執行。
與useEffect不同的是,它不用數組去存儲銷毀和創建函數,而是直接操作fiber.updateQueue。
卸載上壹次的effect,發生在mutation階段
// 調用卸載layout effect的函數,傳入layout有關的effectTag和說明effect有變化的effectTag:HookLayout | HookHasEffect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);
function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
// 獲取updateQueue
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
// 循環updateQueue上的effect鏈表
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// 執行銷毀
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
執行本次的effect創建,發生在layout階段
// 調用創建layout effect的函數
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// 創建
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
useEffect和useLayoutEffect作為組件的副作用,本質上是壹樣的。共用壹套結構來存儲effect鏈表。整體流程上都是先在render階段,生成effect,並將它們拼接成鏈表,存到fiber.updateQueue上,最終帶到commit階段被處理。他們彼此的區別只是最終的執行時機不同,壹個異步壹個同步,這使得useEffect不會阻塞渲染,而useLayoutEffect會阻塞渲染。